使用 2 种方式实现动画的动态蒙版
The following article is from 乐府札记 Author 小明
本篇文章作者:乐府-小明
一. 前言
本文的目的是介绍如何在场景(可能含有多个 spine 动画)上实现动画蒙版(也就是遮罩 mask 会动会变形)根据实现方式的不一样, 会有如下的效果:
二. 阅读本文你可以收获
了解到什么是模板测试
了解到矩阵变换
了解 Cocos Creator 基础的渲染流
了解 Cocos Creator 里如何写一个 shader 并传递 uinform 参数
三. 本文涉及到的素材和代码
一个 spine 动画(从 spine 官方示例中取得)
一个圆形蒙版图片(ps 里随便画一个)
本文中涉及到的所有代码都可以在这里:https://github.com/laomoi/ccc-test-mask 找到 Cocos Creator 工程在 2.3.1 下测试通过。
四.模板测试实现动画蒙版
1. 什么是模板测试(stencil test)
在 opengl 渲染管线中, 当片元着色器处理完着色之后, 着色结果在实际写入颜色缓冲之前, 可以进行模板测试从而可以丢弃一些片元的着色结果。
而 gpu 是如何知道改丢弃那些片元呢? 主要取决于模板缓冲中的值。
模板缓冲区跟颜色缓冲区类似, 它也是一块画布, 我们假设它的分辨率跟屏幕分辨率一样,只是它每个像素的精度是 8 位。
当我们开启了模板测试之后, 某个坐标 (x,y) 的片元颜色在实际写入颜色缓冲之前, gpu 会从模板缓冲区同样的坐标 (x,y) 取到该点的模板值进行后继判断:
当这个值符合我们的设定规则时(比如:该模板值 >0 时不丢弃片元), 则让片元生效, 否则丢弃该片元。
所以我们可以把模板缓冲区理解为一个筛沙子的竹篮子, 值为 0 的地方我们可以认为是镂空的。
当开启了模板测试之后,后继所有的图元绘制都需要在竹篮子里筛一遍, 直到关闭模板测试为止。
要实现我们上文中的图形效果, 我们可以脑补一下模板缓冲中每一帧的变化:
通过这样的模板缓冲变化, 就可以不断的让每帧多显示一些动画内容, 从而实现我们要的效果。
2. 如何在每一帧里正确的填充模板缓冲区来实现我们的目的。
我们只需要按照下面的步骤来通知 webgl/opengl,即可往模板缓冲区里写入我们要的形状:
先清空模板缓冲区, 也就是所有像素的模板值都置为 0
开启模板缓冲的写入
按照正常的图元绘制方式画圆形遮罩图
圆形遮罩图的片元着色器中开启 alpha test,比如我们设定一个 alpha 阈值 0.1,如果片元的 alpha 度大于该阈值, 就把 1 写入模板缓冲区, 否则丢弃该片元(相当于该位置的模板缓冲值保留原 0 的取值)
每一帧都这么执行这几步,只是每一帧里不断把圆形遮罩图的 y 坐标不断的往上增加即可实现我们要的效果。
所幸的是我们不需要自己操作 webgl/opengl 的 api 来做这几步,Cocos Creator 的 cc.Mask 组件已经帮我们做了这几件事:
把我们的圆形遮罩图 (shadow.png) 拖入 mask 组件的 sprite frame 即可。
不过接下来我们遇到的问题是,cc.Mask 目前的设计是比较死板的,它的模板测试只能针对它下面的子节点,在我们这个例子中,节点是这样的关系:
我们希望 hero-mask 这个节点的 y 坐标每帧往上增加一些,这样模板缓冲里的圆形才能往上升,但是我们又希望保持 spine 节点 (hero-pro) 在屏幕上的坐标不动,但是目前 cc.Mask 的设计导致了我们无法同时做到这 2 点,因为移动 hero-mask 的 y 坐标会导致下面的 spine 节点也一起移动。
看上去我们只能祭出魔改引擎这招大杀器了,在打开引擎底层的 cc.Mask 代码阅读之前,我们需要先补习一下 Cocos Creator v2.x 之后的渲染流机制。
Cocos Creator v2.x 的渲染流 (render-flow) 本质上其实跟以前版本并没有太大的区别,只是使用了一个单独的数字 renderFlag 来记录单个节点的所有 dirty 状态。
这个 renderFlag 的每一位 (bit) 记录了一个 dirtyFLag,如图所示。
比如说第 3 位设置为 1, 则表示节点的本地坐标或者缩放等局部属性发生了变化,在绘制这个节点的时候,需要调用 updateLocalMatrix() 来进行更新,本质上其实也是 if 判断。
每个节点绘制的时候,是按照上图表示的顺序, 从上到下进行判断,本文中仅关注以下几个 dirtyFlag 的变化:
LOCAL_TRANSFORM(本地坐标等属性)
WORLD_TRANSFORM(世界坐标等属性)
UPDATE_RENDER_DATA(顶点数据属性)
RENDER(渲染本身,以及其他操作比如开启模板测试)
CHILDREN(遍历子节点)
POST_RENDER(完成所有子节点遍历后的渲染函数,比如用于关闭模板测试)
忽略掉其他不关心的 dirtyFlag,用一个比较简单的伪代码函数来描述整套渲染的流程(跟实际代码并不完全一致,只是可以大体描述):
(想深入学习的可以阅读 Cocos 源码中的 render-flow.js)
上图红框中标记的代码是我们目前最关心的 2 行代码,在 updateRenderData() 中会生成顶点数据,在 fillBuffers() 会把这些顶点数据写入顶点缓冲区。
我们这里不考虑修改 fillBuffers(),我们只需要魔改 cc.Mask.updateRenderData() 函数的实现,让(圆形遮罩图片)的顶点数据每帧向上移动即可,只要没有修改节点本身的本地矩阵和世界矩阵,也就不会影响下面的 2 个 spine 子节点。
2.1 第 1 种魔改方法:修改 assembler.updateWorldVetex()
打开引擎的 mask-assembler.js和2d/simple.js, assembler-2d.js 可以看到如下代码。
hero-mask 这个节点在绘制本身的时候,从updateRenderData() 最终会调用到 updateWorldVertex() 函数,这个函数的内容是我们需要魔改的重点。
首先我们需要先看懂 updateWorldVertex() 里面的这些代码究竟在做什么事情。
这里先简单补习一下矩阵变换的意义。
在图形渲染中, 我们通常使用一个 4x4 的矩阵来表示点的仿射变换(缩放,、旋转,、斜切、移动),虽然在 2D 世界里其实我们用一个 3x3 的矩阵也够用了, 不过为了兼容 3D 世界的坐标系, 所以我们统一使用的是 4x4。
重新看一下我们的节点树结构:
我们使用 M1 矩阵来表示 节点 hero-mask 节点的本地变换矩阵,M2 来表示场景根节点的世界变换矩阵,那么
hero-mask 的世界变换矩阵就是 M3 = M2 x M1,有了这个 M3 之后,我们就可以很方便的把 hero-mask 的任意一个本地坐标换算到世界坐标:
顶点的世界坐标 = M3 x (hero-mask 顶点的本地坐标)
那么这个矩阵和坐标的乘法具体是怎么计算的呢,如下图所示:
回头再来看 updateWorldVertex() 里面的代码,this._local 是圆形遮罩 4 个顶点的本地坐标
com.node._worldMatrix 就是我们上面提到的 M3,也就是hero-mask的世界变换矩阵。
我们把 M3 x 顶点本地坐标 就会得到 4 个顶点的世界坐标, 然后存入 renderData 的 vDatas 数组中。
我们现在魔改的方法就是,我们需要在 M3 x 顶点本地坐标之前,先对顶点本地坐标做一个临时变换,让顶点的本地 y 坐标往上增加之后,再去跟 M3 相乘即可。
在这个例子中我们可以简单的修改 local.y += distance 来达到目的,不过如果以后我们想对圆形遮罩想做一些更复杂的变换,比如缩放旋转之类的,那么就得用一个矩阵变换来做了:
更详细的细节可以看我上面提供的源码。
这种修改 JS 层的 updateWorldVertex 方法在原生平台下并不是总是生效,如果 hero-mask 的父节点每帧都在移动或者变形导致 hero-mask 每帧都会触发原生层重新计算顶点的世界坐标。
因为在 native 层的渲染跟 js 层并不完全一致,比如 maskAssembler 在原生层是在 fillBuffers() 里会判断 world transform 是否 dirty,从而去重新计算顶点的世界坐标。
这样即使在 js 层 updateRenderData() 的时候你修改了顶点的世界坐标数据,但是原生层的 fillBuffers() 是在后面执行的,会导致这个修改失效。
2.2 第 2 种魔改方法:把 mask 节点拆成 2 个节点
根据引擎大佬提供的另外一种思路,我实现了第 2 种魔改方法。
我们通过上述描述应该知道了整个绘制是这样的流程:
hero-mask 节点打开模板测试开关
打开模板缓冲写入,绘制圆形遮罩到模板缓冲上,关闭模板缓冲写入
绘制 hero-mask 节点下的 2 个 spine 子节点
hero-mask 节点关闭模板测试开关
我们把整个显示结构改成下图所示:
begin-mask 和 end-mask 节点都挂载了自定义的 mask 组件:
只是一个开启了 fillBuffers() 用来开启模板测试,另外一个开启了 postBuffers() 用来关闭模板测试。
同样的我们在代码里让 beginMask 这个节点做一个向上运动动画即可,因为这次 spine 节点不再是 beginMask 节点的子节点,所以 beginMask 的移动不会影响到 spine 节点。
整个显示效果跟第一个魔改方法一样。
注意这种修改方法,在原生层要生效的话需要修改 C++ 代码, 因为原生层的 fillBuffers 和 PostFillBuffers() 是否调用跟 JS 层无关。
在上面提供的源码里我同时也修改了 C++ 代码里的 MaskAssembler.cpp 代码,增加了 setBeginMask()和setEndMask() 方法,有兴趣可以看一下。
五. 自定义材质实现动画蒙版
上面使用模板测试的方法来实现遮罩, 它的优点是实现简单, 而且支持多层遮罩嵌套(最多 8 层)缺点是遮罩边缘比较生硬。
如果大家使用过 photoshop 等美术工具里面的图层蒙版,就会发现这些美术工具里的蒙版是支持边缘羽化的,我猜测他们实际上就是用了一张带透明度的蒙版纹理,覆盖在原图层上面。
原图层某像素的颜色 .alpha *= 蒙版纹理在在该像素点上的 alpha 通过这种方式就可以实现类似片元剔除和羽化的功能。
实现原理非常简单,缺点是它需要修改需要被遮罩的所有子节点的片元着色器,也就是修改他们的材质,在新的片元着色器中传入这张遮罩图的纹理,进行纹理采样之后 再乘上原来片元的颜色即可。
也就是对片元着色器做如下修改即可:
红色框是我们在片元着色器中新加的代码,texture2 是那张半透明的白色遮罩图的纹理,mask_uv 是一个 uv 坐标,是一个 vec2 的结构,表示对应在渲染该片元时,应该去白色遮罩图的哪个坐标上进行采样从而得到叠加的 alpha 度。(关于什么是 uv 坐标这里不阐述)
如何在片元着色器中得到一个正确的 mask_uv 是我们接下来要重点解决的问题。
如下图所示:
当我们在渲染这个 spine 节点的头发上这个蓝色点时,如果我们能知道这个蓝色点的世界坐标,那我们就可以反推计算这个点在这个圆形白色遮罩图上的本地坐标,得到这个本地坐标之后,再进行坐标映射,就可以得到这个坐标对应的 mask_uv 值。
我们在上面的模板测试中提到, 一个本地坐标 localPt 乘以一个世界变换矩阵 M 就可以得到世界坐标 worldPt,同理我们可以知道,把一个世界坐标 worldPt 乘以这个 M 的逆矩阵就可以得到对应的 localPt,也就是:
worldPt = M x localPt
localPt = M的逆矩阵 x worldPt
(计算一个矩阵的逆矩阵,Cocos Creator 已经给我们提供了方法:mat4.invert())
接下来要做一个本地坐标到 uv 坐标的映射变换,因为我们得到的是图片内部的本地坐标 (x,y),图片的中心点坐标是 (0,0),最终我们要得到 uv 坐标范围是 [0,1] 的值,已知图片的宽度 w,高度h:
很容易可以算出来:
u = (x + w/2) / w = x/w + 0.5
v = (y + h/2) /h = y/h + 0.5
我们可以把这个纹理坐标的映射放入顶点着色器里去算, 但是这样需要把 w 和 h 当作 uniform 传入顶点着色器。
所以我们干脆再对原来计算好的逆矩阵把纹理坐标引射的计算叠加上去,整个代码如下:
顶点着色器里代码如下:
有几点需要注意的:
我们在传递一个矩阵数据给片元着色器时,需要把这个 4x4 的矩阵 转换为一个 float32 的数组。
圆形白色遮罩这张纹理,在编辑器里必须关掉 Packable 的属性,如果这个纹理被动态合入了大纹理集,那uv坐标就没法正确计算。
Cocos Creator 的默认顶点着色器里有一个 CC_USE_MODEL 的宏, 当定义了这个宏的时候,意味着顶点数据里的顶点数据并不是世界坐标,我们需要额外再乘上一个 cc_matWorld 才能得到我们要的世界坐标。
spine 的着色器跟上图代码中贴出来的略有不同,有兴趣的可以看下上面我提供的源码。
到这里为止我们已经提供了 2 种不同方式实现动画的动态蒙版,在实际项目开发时,我们大部分情况并不会用代码的方式来控制这个圆形遮罩的运动,而是让美术在编辑器里把整套动画都做好。
当然现在这套编辑器目前仅是公司内使用,等以后或许有机会把编辑器放出来给大家试用。
在后面的时间里,我们会陆续给大家分享一些我们项目中用到的一些魔改技巧或者图形效果之类的,希望可以给 Cocos 社区贡献一份绵薄之力。
以上是由 Cocos 开发者 乐府-小明 分享的优质技术教程,此文同时参加了 Cocos 中文社区征稿活动,入选优秀稿件。欢迎各位开发者点击【阅读原文】查看原文,为作者点赞,与作者进行交流学习!
如果您在使用 Cocos 引擎的过程中,获得了独到的开发心得、见解或是方法,并且乐于分享出来,帮助更多开发者解决技术问题,加速游戏开发效率,期待您与我们联系!